<?php
/**
 * 微信公众号网页授权信息与授权
 *
 * @example /public/test/wx-oauth.html - 这里有示例
 *
 * Author: Zeon
 * Date: 2017/7/25
 * Created by Panxsoft.
 */
namespace App\Http\Controllers\OpenWechat;

use App\Exceptions\ApiErrCode;
use App\Http\Controllers\ApiController;
use App\Http\Requests\OpenWechat\Authorizer\OauthRequest;
use App\Http\Requests\OpenWechat\OAuthCallBackRequest;
use App\Models\OpenWechat\Authorizer;
use App\Models\OpenWechat\WechatUser;
use App\Services\OpenWechat\AppFactory;
use App\Services\OpenWechat\Mock;
use App\Services\OpenWechat\WechatUserRepository;
use App\Services\Utils\UrlParser;
use EasyWeChat\OpenPlatform\Authorizer\OfficialAccount\Application;
use Illuminate\Http\Request;
use Overtrue\Socialite\Providers\WeChatProvider;
use Overtrue\Socialite\User;

class OAuthController extends ApiController
{
    const SESSION_REDIRECT_URL = 'redirect_url';
    const SESSION_OPEN_ID = 'openid';
    const SESSION_USER_INFO = 'wechat_user_info';
    const SESSION_SCOPE = 'scope';
    const SESSION_AUTHORIZER_APPID = 'authorizer_appid';

    const SCOPE_BASE = 'snsapi_base';
    const SCOPE_USER_INFO = 'snsapi_userinfo';

    const OAUTH_ROUTE = 'wx_oauth';
    const OAUTH_CALL_BACK_ROUTE = 'wx_oauth_callback';

    /**
     * OAuthController constructor.
     * @param Request $request
     */
    public function __construct(Request $request)
    {
        if ($request->debug) $this->mockSessionUserInfo();
    }

    /**
     * 获取当前微信用户的 openid
     *
     * @param Request $request
     *
     * @return array
     * @throws \App\Exceptions\ApiException
     */
    public function openid(Request $request)
    {
        $session_id = $this->getSessionIdFromAppid($request);

        $openid = session($session_id);
            $this->checkItemExist($openid, 'session openid not found',
                ApiErrCode::WX_SESSION_OPENID_NOT_FOUND);

        // 默认删除缓存，指明缓存才缓存 user_info
        if (! $this->shouldCacheOpenId($request)) {
            session()->forget($session_id);
        }

        return $this->response(compact('openid'));
    }

    /**
     * 获取当前微信用户的 user info
     *
     * @param Request $request
     *
     * @return array
     * @throws \App\Exceptions\ApiException
     */
    public function userInfo(Request $request)
    {
        /** @var WechatUser $user_info */
        $user_info = session(self::SESSION_USER_INFO);
            $this->checkItemExist($user_info);

        // 默认删除缓存，指明缓存才缓存 user_info
        if (! $request->exists('cache')) session()->forget(self::SESSION_USER_INFO);

        $user_info->nickname = $user_info->decodeNickName();

        return $this->response(compact('user_info'));
    }

    /**
     * 前端页面无法获取用户信息，说明未授权，需要请求此接口方法授权
     *
     * @used-by oauthCallback()
     *
     * @param OauthRequest $request
     * @return \Symfony\Component\HttpFoundation\RedirectResponse
     */
    public function oauth(OauthRequest $request)
    {
        $redirect_url = urldecode($request->redirect_url);
        $scope = $request->scope ? self::SCOPE_USER_INFO : self::SCOPE_BASE;

        session([
            self::SESSION_REDIRECT_URL     => $redirect_url,
            self::SESSION_SCOPE            => $scope,
            self::SESSION_AUTHORIZER_APPID => $request->appid,
        ]);

        $app = (new AppFactory)->authorizerApp($request->appid);
        /** @var WeChatProvider $oauth */
        $oauth = $app->oauth;

        // snsapi_base OR snsapi_userinfo
        $response = $oauth->scopes([$scope])
            ->redirect($this->getOAuthCallBackUrl());

        return $response;
    }

    /**
     * 上面的授权方法成功后，微信会带上参数并返回这个授权回调地址
     *
     * @uses oauth()
     *
     * @param OAuthCallBackRequest $request
     *
     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
     * @throws \Exception
     */
    public function oauthCallBack(OAuthCallBackRequest $request)
    {
        $appid = $request->appid ?: session(self::SESSION_AUTHORIZER_APPID);

        $app = (new AppFactory)->authorizerApp($appid);

        $authorizer = Authorizer::findByAppId($appid);

        $user = $this->retryGetOAuthUser($app);

        if ($this->isOAuthScopeBase()) {
            $wechat_user_info = $this->getWechatUserInfo($app, $user);

            if ($this->isWechatUserUnSubscribe($wechat_user_info)) {
                return redirect($this->userInfoOAuthUrl($appid));
            }

            /** @var WechatUser $wechat_user */
            $wechat_user = $this->updateWechatQueryUserInfo($wechat_user_info, $authorizer);
        } else {
            $wechat_user = $this->updateWechatOauthUserInfo($user, $authorizer);
        }

        $appid_openid_session_key = $this->getAppIdOpenIdSessionKey($appid);

        /** temp save */
        session([
            self::SESSION_OPEN_ID     => $wechat_user->openid,
            $appid_openid_session_key => $wechat_user->openid,
            self::SESSION_USER_INFO   => $wechat_user,
        ]);

        $target_url = session(self::SESSION_REDIRECT_URL) ?: '/';

        $target_url = UrlParser::insertQueryToUrl($target_url, [
            'openid' => $wechat_user->openid,
            'appid'  => $appid,
            '_v'     => rand(1, 99999) // wechat web page cache is horrible
        ]);

        return redirect($target_url);
    }

    /**
     * 通过 easy wechat 返回的 user->OpenId 数据查询 wechat 用户信息
     *
     * @param Application $app
     * @param User $user
     * @return array
     * @throws \EasyWeChat\Kernel\Exceptions\InvalidConfigException
     */
    private function getWechatUserInfo(Application $app, User $user)
    {
        return $app->user->get($user->getId());
    }

    /**
     * 保存通过微信用户信息查询接口（用户必须已关注公众号），返回用户数据
     * @param array $user_info
     * @param Authorizer $authorizer
     * @return WechatUser
     */
    private function updateWechatQueryUserInfo(array $user_info, Authorizer $authorizer)
    {
        $user_info['authorizer_id'] = $authorizer->id;

        $user_repo = new WechatUserRepository();

        $wechat_user = $user_repo->createOrUpdateWechatUserInfo($user_info);

        return $wechat_user;
    }

    /**
     * base 网页静默授权发现未订阅需要重新用 user info 授权
     *
     * @param string $appid
     *
     * @return string
     */
    private function userInfoOAuthUrl(string $appid)
    {
        return route(self::OAUTH_ROUTE, [
            'redirect_url' => urlencode(session(self::SESSION_REDIRECT_URL)),
            'scope'        => self::SCOPE_USER_INFO,
            'appid'        => $appid,
        ]);
    }

    /**
     * 保存通过 oauth 接口授权（微信用户未关注公众号，但在网页中授权给公众号用户权限），查询的用户数据
     *
     * @param User $user
     * @param Authorizer $authorizer
     * @return WechatUser
     */
    private function updateWechatOauthUserInfo(User $user, Authorizer $authorizer)
    {
        /** @var array $user_info */
        $user_info = $this->getOriginUserInfo($user);
            $this->checkUserInfoHasOpenid($user_info);

        /** @var WechatUser $wechat_user */
        $wechat_user = WechatUser::firstOrCreateByOpenId($user_info['openid']);

        $user_info['authorizer_id'] = $authorizer->id;

        $wechat_user->updateOauthOriginUserInfo($user_info);

        return $wechat_user;
    }

    /**
     * @param User $user
     * @return array
     */
    private function getOriginUserInfo(User $user)
    {
        /**
         * @see Mock::userGetOriginal()
         */
        return $user->getOriginal();
    }

    /**
     * 判断微信用户是否已取关公众号
     *
     * @param array $wechat_user_info
     * @return bool
     */
    private function isWechatUserUnSubscribe(array $wechat_user_info)
    {
        return (int) $wechat_user_info['subscribe'] === WechatUser::UN_SUBSCRIBE;
    }

    /**
     * 判断是不是网页静默授权（微信用户必须已关注了公众号）
     *
     * @return bool
     */
    private function isOAuthScopeBase()
    {
        return session(self::SESSION_SCOPE) === self::SCOPE_BASE;
    }

    /**
     * 如果请求需要假数据，在session里注入假的数据
     */
    private function mockSessionUserInfo()
    {
        $mock_user = WechatUser::mockUser();

        /** temp save */
        session([
            self::SESSION_OPEN_ID   => $mock_user->openid,
            self::SESSION_USER_INFO => $mock_user
        ]);
    }

    /**
     * @return string
     */
    private function getOAuthCallBackUrl(): string
    {
        // APP_URL should be right
        return route(self::OAUTH_CALL_BACK_ROUTE);
    }

    /**
     * 主要解决不同公众号切换缓存返回同一个 openid 的问题， 直接将 appid 存成 session 的 key
     *
     * @param string $appid
     *
     * @return string
     */
    private function getAppIdOpenIdSessionKey(string $appid): string
    {
        return self::SESSION_AUTHORIZER_APPID . ":" . $appid . ':' . self::SESSION_OPEN_ID;
    }

    /**
     * @param Request $request
     *
     * @return string
     */
    private function getSessionIdFromAppid(Request $request): string
    {
        return isset($request->appid)
            ? $this->getAppIdOpenIdSessionKey($request->appid)
            : self::SESSION_OPEN_ID;
    }

    /**
     * @param Request $request
     *
     * @return bool
     */
    private function shouldCacheOpenId(Request $request): bool
    {
        return $request->exists('cache') && ! config('app.debug');
    }

    /**
     * @param array $user_info
     */
    private function checkUserInfoHasOpenid(array $user_info)
    {
        if (! isset($user_info['openid'])) {
            /**
             * @see Mock::userGetOriginalFailed()
             */
            logger($user_info);
            $this->checkFailed(ApiErrCode::LOG_PREFIX . 'wx oauth failed');
        }
    }

    /**
     * @param $app
     *
     * @return User
     * @throws \Exception
     * @throws \Psr\SimpleCache\InvalidArgumentException
     */
    private function getOAuthUserFromCode(Application $app): User
    {
        /** @var WeChatProvider $oauth */
        $oauth = $app->oauth;

        try {
            /** @var User $user */
            $user = $oauth->user();
        } catch (\Exception $e) { // 线上这里可能报 40163 code been used 等错误 WIP
            $this->checkFailed($e->getMessage(), $e->getCode());
        }

        // 此时 $user->getOriginal()['openid'] 有可能为空，后面会导致报错
        if (! isset($user->getOriginal()['openid'])) {
            // force refresh token
            $app->access_token->getToken(true);
            $this->checkFailed('openid not found, force refresh token then retry');
        }

        return $user;
    }

    /**
     * @param $app
     *
     * @return mixed
     * @throws \Exception
     */
    private function retryGetOAuthUser(Application $app): User
    {
        $user = retry(3, function () use ($app) {
            return $this->getOAuthUserFromCode($app);
        });

        return $user;
    }

}
